Callback Signature Verification
After you save a URL in Enterprise Callback URLs in the Developer Center, the platform issues an AppId and a Secret. Every push afterwards (including the Test action on the page) carries an HMAC signature. Verify the signature in your receiving service before processing the payload.
The AppId only identifies which subscription the push belongs to. It appears in both the JSON body and the request headers, so it must never be your only allow condition.
The Secret is only used to compute signatures. Never ship it to a frontend or write it to logs.
Request Convention
All pushes are HTTPS POST with Content-Type: application/json.
| Header | Description |
|---|---|
X-Gausium-App-Id | Matches the AppId in the Developer Center |
X-Gausium-Timestamp | Millisecond timestamp. Reject the request when it differs from your local time by more than 5 minutes |
X-Gausium-Nonce | Random string. Caching it for 10 minutes for deduplication is recommended |
X-Gausium-Signature | sha256=<hex>, HMAC-SHA256(Secret) |
The signing string must use the raw body bytes (never parse and re-serialize the body first):
timestamp + "\n" + nonce + "\n" + rawBody
Compute HMAC-SHA256(Secret, signing string), encode the result as lowercase hex, and compare it against the header value with sha256= stripped, using a constant-time comparison.
Test payloads carry payload.test = true. Once verified, just return 200. Do not treat them as real incidents or tasks.
Verification Steps
- Only expose an
https://receiving URL. - Reject the request when any of the four headers is missing.
X-Gausium-App-Idmust equal this subscription's AppId.|nowMs - timestamp| < 5 × 60 × 1000.- Compute the signature as described above and compare it in constant time.
- Only parse the JSON after verification passes. Return 4xx on failure and do not persist anything.
Legacy subscriptions that only carry appId + payload without a Secret are still identified by the AppId in the body. Newly issued subscriptions must always be verified.
Examples
Python
import hmac, hashlib, time
def verify(headers, raw_body: bytes, app_id: str, secret: str) -> bool:
got_app = headers.get("X-Gausium-App-Id", "")
ts = headers.get("X-Gausium-Timestamp", "")
nonce = headers.get("X-Gausium-Nonce", "")
sig = headers.get("X-Gausium-Signature", "")
if got_app != app_id or not ts or not nonce or not sig.startswith("sha256="):
return False
if abs(int(time.time() * 1000) - int(ts)) > 5 * 60 * 1000:
return False
msg = f"{ts}\n{nonce}\n".encode() + raw_body
expect = "sha256=" + hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(expect, sig)
Node.js
const crypto = require("crypto");
function verify(headers, rawBodyBuf, appId, secret) {
const gotApp = headers["x-gausium-app-id"];
const ts = headers["x-gausium-timestamp"];
const nonce = headers["x-gausium-nonce"];
const sig = headers["x-gausium-signature"] || "";
if (gotApp !== appId || !ts || !nonce || !sig.startsWith("sha256=")) return false;
if (Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return false;
const msg = Buffer.concat([Buffer.from(`${ts}\n${nonce}\n`), rawBodyBuf]);
const expect = "sha256=" + crypto.createHmac("sha256", secret).update(msg).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expect), Buffer.from(sig));
}
Java
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + "\n" + nonce + "\n").getBytes(StandardCharsets.UTF_8));
mac.update(rawBody);
String expect = "sha256=" + Hex.encodeHexString(mac.doFinal());
return MessageDigest.isEqual(
expect.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8));
Connectivity Test
The Test action in the Developer Center POSTs a probe payload with payload.test = true to the URL in the form, exactly once, without changing the saved configuration. When a Secret already exists, the probe carries the same four signature headers as real pushes, so you can use it to validate your verification logic first.
If the peer returns a non-2xx status or is unreachable, the API itself still returns code = 0 and reports the outcome through data.httpCode and data.reachable, so the page can distinguish "URL unreachable" from "invalid parameters".
Related Error Codes
| Error code | Description | Trigger condition |
|---|---|---|
| 20059 | Callback URL is required | The URL is empty during a test |
| 20060 | Callback URL is invalid | The URL does not start with http:// or https://, or exceeds 150 characters |